package uk.co.q3c.deplan.server.domain;

import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.EnumMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;


import uk.co.q3c.deplan.client.domain.task.Target;
import uk.co.q3c.deplan.client.util.apache.StringUtils;
import uk.co.q3c.deplan.server.domain.DomainMatchingLog.Outcome;
import uk.co.q3c.deplan.server.util.ReflectionFieldBuilder;
import uk.co.q3c.deplan.server.util.ReflectionFieldBuilder.FieldTypes;

/**
 * Compares two domain objects for equal field values.<br>
 * <br>
 * All fields are compared, private, protected or public<br>
 * <br>
 * Comparison can be deep <code>deepCompare=true</code>, which cascades the comparison down through all nested objects, otherwise the
 * comparison is made only of the fields directly contained within the supplied objects. Default is true<br>
 * <br>
 * 
 * Comparison is made using the string representation of the fields' values, including the toString() method of contained objects.
 * Collections are compared entry by entry<br>
 * <br>
 * 
 * if <code>breakOnFail</code> is true, comparison ceases as soon as a mismatch is found. Default is false. Note however, that this only
 * affects behaviour when comparing values. Any failure to meet rules set by <code>strict</code> and <code>subClassOnly</code> will cause a
 * break on fail regardless of the setting of this parameter. Note also that if a contained object fails a match it is not compared any
 * further (that is, it isn't drilled down into)<br>
 * <br>
 * 
 * if criteria is set to <code>STRICT</code> objectA and objectB (and all nested objects) must be of the same class. If set to
 * <code>SUBCLASS</code> , then <code>objectB</code> must be an <b> <code>instanceof</code></b> <code>objectA</code>. if set to
 * <code>ANY</code> objects can be of any class. Default is STRICT.<br>
 * <br>
 * An array counts as an object, and its elements as fields.<br>
 * <br>
 * A collection or set counts as an object, and its elements as fields.<br>
 * 
 */

public class DomainObjectComparator {

	public enum Criteria {
		STRICT, SUBCLASS, ANY
	}

	// Object objectA;
	// Object objectB;
	boolean deepCompare = true;
	boolean breakOnFail = true;
	Criteria criteria = Criteria.STRICT;
	DomainMatchingLog log;
	ReflectionFieldBuilder rfb = new ReflectionFieldBuilder();
	ArrayList<String> excludedFields = new ArrayList<String>();
	ArrayList<Class<?>> excludedClasses = new ArrayList<Class<?>>();

	public DomainObjectComparator() {
		log = new DomainMatchingLog();
	}

	/**
	 * Compares objectA and objectB according to the current rule settings. The resultant log can be obtained using {@link #getLog()}.
	 * ObjectA and ObjectB are stored so the test could be rerun using different settings. If so, you might want to replace the log using
	 * {@link #setLog()}
	 * 
	 * @return true if matched according to the rules set
	 */
	public Outcome compare(Object objectA, Object objectB) {
		// this.objectA = objectA;
		// this.objectB = objectB;

		log.clear();

		Outcome result = processInitialObject(objectA, objectB);
		return result;
	}

	private Outcome checkNullsAndRules(Object objectA, Object objectB) {
		if (isExcluded()) {
			return Outcome.EXCLUDED;
		}

		if (log.maxFailsExceeded()) {
			return Outcome.STOP;
		}
		// both null therefore the same
		if ((objectA == null) && (objectB == null)) {
			return Outcome.PASS;
		}
		// if either one is false now, they can't be the same
		if ((objectA == null) || (objectB == null)) {
			return Outcome.FAIL;
		}

		if (log.hasLoopWarning()) {
			return Outcome.LOOP;
		}

		switch (criteria) {
		case STRICT:
			boolean sameClass = (objectA.getClass() == objectB.getClass());
			if (!sameClass) {
				log.add("objectA ('" + objectA + "') is not the same class as objectB ('" + objectB + "')");
				log.add("objectA = " + objectA.getClass().getName() + " objectB = " + objectB.getClass().getName());
				log.add("Strict check failed, object A is " + objectA.getClass().getName() + ", object B is " + objectB.getClass().getName());

				return Outcome.RULEFAIL;
			}
			break;
		case SUBCLASS:

			try {
				objectA.getClass().cast(objectB);
			} catch (Exception e) {
				log.add("objectA ('" + objectA + "') must be the same class as objectB or a superclass of objectB ('" + objectB + "')");
				log.add("objectA = " + objectA.getClass().getName() + " objectB = " + objectB.getClass().getName());
				log.add("Subclass check failed, object A is " + objectA.getClass().getName() + ", object B is " + objectB.getClass().getName());

				return Outcome.RULEFAIL;
			}

			break;
		case ANY: // do nothing
			break;
		}
		return Outcome.PASS;
	}

	private boolean isExcluded() {
		if (excludedFields.contains(log.fieldNameStack())) {
			return true;
		}
		if (log.getCurrentMatchA() != null) {
			if (excludedClasses.contains(log.getCurrentMatchA().getClass())) {
				return true;
			}
		}
		return false;
	}

	private Outcome processInitialObject(Object objectA, Object objectB) {
		return processUndefinedObject(objectA, objectB, "root");
	}

	/**
	 * A general object is what's left over after primitives, arrays, strings, enums, collections and maps have been separated out.
	 */
	@SuppressWarnings("unchecked")
	private Outcome processGeneralObject(Object objectA, Object objectB, String fieldName) {
		// log.startObject(objectA, objectB, fieldName);
		// Outcome objectResult = checkNullsAndRules(objectA, objectB);
		// if (objectResult != Outcome.PASS) {
		// log.endObject(FieldTypes.GENERALOBJECT, objectResult, objectA,
		// objectB);
		// return objectResult;
		// }
		if (log.maxFailsExceeded()) {
			return Outcome.STOP;
		}

		EnumMap<FieldTypes, List<Field>> map = rfb.buildFieldList(objectA);
		Outcome objectResult = Outcome.PASS;
		Field fieldA;
		Field fieldB;
		Object fobA = null;
		Object fobB = null;
		Outcome fieldResult = Outcome.PASS;

		for (FieldTypes fieldType : FieldTypes.values()) {
			for (Field field : map.get(fieldType)) {
				fieldA = field;
				fieldB = findField(objectB.getClass(), fieldA.getName());
				try {
					fieldA.setAccessible(true);
					fieldB.setAccessible(true);
					fobA = fieldA.get(objectA);
					fobB = fieldB.get(objectB);
				} catch (Exception e) {
					log.addError(e.getMessage());
				}

				log.startObject(fobA, fobB, fieldA.getName());
				fieldResult = checkNullsAndRules(fobA, fobB);
				if (fieldResult == Outcome.PASS) {
					// if both are null it will get to here
					if (fobA != null) {
						switch (fieldType) {
						case PRIMITIVE:

							fieldResult = Outcome.result(fobA.toString().equals(fobB.toString()));
							break;
						case ARRAY:
							fieldResult = processArray(fobA, fobB);
							break;
						case COLLECTION:
							fieldResult = processCollection((Collection) fobA, (Collection) fobB);
							break;
						case ENUM:
							Target a = Target.valueOf(fobA.toString());
							Target b = Target.valueOf(fobB.toString());
							fieldResult = Outcome.result(a == b);
							break;
						case MAP:
							fieldResult = processMap((Map) fobA, (Map) fobB);
							break;
						case GENERALOBJECT:
							fieldResult = processGeneralObject(fobA, fobB, fieldA.getName());
							break;
						case STRING:
							fieldResult = Outcome.result(StringUtils.equals((String) fobA, (String) fobB));
							break;
						}
					} else {
						// both A and B were null
						fieldResult = Outcome.result(true);
					}

				}
				if (DomainMatchingLog.failSet.contains(fieldResult)) {
					objectResult = fieldResult;
				}
				log.endObject(fieldType, fieldResult, fobA, fobB);
			}
		}
		// log.endObject(FieldTypes.GENERALOBJECT, objectResult, objectA,
		// objectB);
		return objectResult;
	}

	private Outcome processCollection(Collection<Object> objectA, Collection<Object> objectB) {
		Collection<Object> collectionA = objectA;
		Collection<Object> collectionB = objectB;

		// if collections aren't the same length, fail
		if (collectionA.size() != collectionB.size()) {
			return Outcome.FAIL;
		}

		Outcome objectResult = Outcome.PASS;

		Iterator<Object> iteratorA = collectionA.iterator();
		Iterator<Object> iteratorB = collectionB.iterator();
		int i = 0;
		while (iteratorA.hasNext()) {
			Object elementA = iteratorA.next();
			Object elementB = iteratorB.next();

			String fieldName = "element " + Integer.toString(i);
			// any element failure fails the object
			Outcome fieldResult = processUndefinedObject(elementA, elementB, fieldName);
			if (DomainMatchingLog.failSet.contains(fieldResult)) {
				objectResult = fieldResult;
			}
			i++;
			if (log.maxFailsExceeded()) {
				return Outcome.STOP;
			}
		}
		return objectResult;

	}

	@SuppressWarnings("unchecked")
	private Outcome processUndefinedObject(Object objectA, Object objectB, String fieldName) {
		if (log.maxFailsExceeded()) {
			return Outcome.STOP;
		}

		log.startObject(objectA, objectB, fieldName);
		Outcome objectResult = checkNullsAndRules(objectA, objectB);
		if (DomainMatchingLog.failSet.contains(objectResult)) {
			log.endObject(FieldTypes.UNDEFINEDOBJECT, objectResult, objectA, objectB);
			return objectResult;
		}

		if (objectA.getClass().isEnum()) {
			objectResult = Outcome.result(objectA.equals(objectB));
			log.endObject(FieldTypes.ENUM, objectResult, objectA, objectB);
			return objectResult;
		}

		if (objectA instanceof String) {
			objectResult = Outcome.result(StringUtils.equals((String) objectA, (String) objectB));
			log.endObject(FieldTypes.STRING, objectResult, objectA, objectB);
			return objectResult;
		}

		if (objectA instanceof Collection) {
			objectResult = processCollection((Collection) objectA, (Collection) objectB);
			log.endObject(FieldTypes.COLLECTION, objectResult, objectA, objectB);
			return objectResult;
		}

		if (objectA instanceof Map) {
			objectResult = processMap((Map) objectA, (Map) objectB);
			log.endObject(FieldTypes.MAP, objectResult, objectA, objectB);
			return objectResult;
		}

		objectResult = processGeneralObject(objectA, objectB, fieldName);
		log.endObject(FieldTypes.GENERALOBJECT, objectResult, objectA, objectB);
		return objectResult;
	}

	private Outcome processMap(Map<Object, Object> objectA, Map<Object, Object> objectB) {
		if (log.maxFailsExceeded()) {
			return Outcome.STOP;
		}
		Map<Object, Object> mapA = objectA;
		Map<Object, Object> mapB = objectB;
		Set<Object> keySetA = mapA.keySet();
		Set<Object> keySetB = mapB.keySet();
		Outcome objectResult = Outcome.PASS;

		Outcome fieldResult = processCollection(keySetA, keySetB);
		if (DomainMatchingLog.failSet.contains(fieldResult)) {
			objectResult = fieldResult;
			return objectResult;
		}

		Collection<Object> valuesA = mapA.values();
		Collection<Object> valuesB = mapB.values();
		fieldResult = processCollection(valuesA, valuesB);
		if (DomainMatchingLog.failSet.contains(fieldResult)) {
			objectResult = fieldResult;
		}
		return objectResult;

	}

	private Field findField(Class<? extends Object> cls, String fieldName) {
		Field field = null;
		while (cls != null) {
			try {
				field = cls.getDeclaredField(fieldName);
				break;
			} catch (NoSuchFieldException ex) {
				cls = cls.getSuperclass();
			}
		}
		return field;
	}

	private Outcome processArray(Object fieldObjectA, Object fieldObjectB) {
		if (log.maxFailsExceeded()) {
			return Outcome.STOP;
		}
		if (fieldObjectA instanceof int[]) {
			return Outcome.result(Arrays.equals((int[]) fieldObjectA, (int[]) fieldObjectB));
		}
		if (fieldObjectA instanceof double[]) {
			return Outcome.result(Arrays.equals((double[]) fieldObjectA, (double[]) fieldObjectB));
		}

		if (fieldObjectA instanceof boolean[]) {
			return Outcome.result(Arrays.equals((boolean[]) fieldObjectA, (boolean[]) fieldObjectB));
		}

		if (fieldObjectA instanceof byte[]) {
			return Outcome.result(Arrays.equals((byte[]) fieldObjectA, (byte[]) fieldObjectB));
		}

		if (fieldObjectA instanceof short[]) {
			return Outcome.result(Arrays.equals((short[]) fieldObjectA, (short[]) fieldObjectB));
		}

		if (fieldObjectA instanceof long[]) {
			return Outcome.result(Arrays.equals((long[]) fieldObjectA, (long[]) fieldObjectB));
		}

		if (fieldObjectA instanceof char[]) {
			return Outcome.result(Arrays.equals((char[]) fieldObjectA, (char[]) fieldObjectB));
		}
		if (fieldObjectA instanceof float[]) {
			return Outcome.result(Arrays.equals((float[]) fieldObjectA, (float[]) fieldObjectB));
		}

		// must be object
		Outcome objectResult = Outcome.PASS;
		Object[] arrayA = (Object[]) fieldObjectA;
		Object[] arrayB = (Object[]) fieldObjectB;
		for (int i = 0; i < arrayA.length; i++) {
			Outcome fieldResult = processUndefinedObject(arrayA[i], arrayB[i], "element " + Integer.toString(i));
			if (DomainMatchingLog.failSet.contains(fieldResult)) {
				objectResult = fieldResult;
			}
			if (log.maxFailsExceeded()) {
				return Outcome.STOP;
			}
		}
		return objectResult;
	}

	public DomainMatchingLog getLog() {
		return log;
	}

	public boolean isDeepCompare() {
		return deepCompare;
	}

	public void setDeepCompare(boolean deepCompare) {
		this.deepCompare = deepCompare;
	}

	public boolean isBreakOnFail() {
		return breakOnFail;
	}

	public void setBreakOnFail(boolean breakOnFail) {
		this.breakOnFail = breakOnFail;
	}

	public Criteria getCriteria() {
		return criteria;
	}

	public void setCriteria(Criteria criteria) {
		this.criteria = criteria;
	}

	public void setLog(DomainMatchingLog log) {
		this.log = log;
	}

	public void addExcludedField(String fieldPath) {
		excludedFields.add(fieldPath);
	}

	public void addExcludedClass(Class<?> clazz) {
		excludedClasses.add(clazz);
	}
}

// Class<? extends Object> c = DomainMatching_UT.class;
// Field[] field = c.getFields();
// Field[] decField = c.getDeclaredFields();

